import React, { useState, useEffect, useMemo } from 'react';
import {
Wrench,
Settings,
User,
TrendingUp,
Plus,
PlusCircle,
ClipboardCheck,
Truck,
Shield,
Activity,
FileText,
AlertTriangle,
Trash2,
Edit,
Search,
Download,
RefreshCw,
CheckCircle,
XCircle,
Clock,
Eye,
ChevronRight,
Package,
Disc,
Navigation,
Check,
BarChart2,
ShieldAlert,
Calendar,
Users,
UserCheck,
FileSpreadsheet,
Layers,
ExternalLink,
LogOut,
Camera,
Upload,
Info,
MapPin,
Maximize2,
AlertCircle
} from 'lucide-react';
const MECHANICS_LIST = [
"Alvi", "Wahiyo", "Topik", "Chandra", "Apri", "Obi", "Dwi", "Khabul", "Dian", "Ega",
"Gagah", "Rio", "Agung", "Agus", "Eko", "Deril", "Wisnu", "Tegar", "Anja", "Wahyu",
"Anas", "Rabani"
];
const CHECKERS_LIST = [
"Wahyu", "Bono", "Andi", "Firdaus"
];
// 1. GENERATE 100 UNIT BUS TSW (TSW 001 - TSW 100) OTOMATIS
const FLEET_UNITS = Array.from({length: 100}, (_, i) => `TSW ${String(i + 1).padStart(3, '0')}`);
const DEFAULT_SPAREPARTS = [
{ kode: "SP-BRK-01", nama: "Kampas Rem Depan Heavy Duty (Set)", qty: 24, unit: "Set", minQty: 5 },
{ kode: "SP-OIL-15W", nama: "Oli Mesin Synthetic 15W-40", qty: 210, unit: "Liter", minQty: 40 },
{ kode: "SP-FIL-O1", nama: "Filter Oli Bus Scania Type-A", qty: 15, unit: "Pcs", minQty: 4 },
{ kode: "SP-BELT-99", nama: "Fan Belt High-Tension v3", qty: 12, unit: "Pcs", minQty: 3 },
{ kode: "SP-TIRE-295", nama: "Ban Radial Tubeless 295/80 R22.5", qty: 18, unit: "Pcs", minQty: 4 },
{ kode: "SP-ALT-24V", nama: "Alternator Heavy Duty 24V 150A", qty: 3, unit: "Pcs", minQty: 2 },
{ kode: "SP-CLU-M1", nama: "Clutch Plate Master Kit Scania", qty: 4, unit: "Set", minQty: 2 }
];
const DEFAULT_VEHICLES_PM = [
{ noBody: "TSW 001", currentKM: 45200, nextPMTarget: 50000, model: "Scania K360IB", status: "Aman" },
{ noBody: "TSW 045", currentKM: 119850, nextPMTarget: 120000, model: "Mercedes-Benz OH 1626", status: "Mendekati PM" },
{ noBody: "TSW 022", currentKM: 92120, nextPMTarget: 90000, model: "Hino RK8", status: "Overdue" },
{ noBody: "TSW 089", currentKM: 201300, nextPMTarget: 210000, model: "Volvo B11R", status: "Aman" },
{ noBody: "TSW 100", currentKM: 15150, nextPMTarget: 20000, model: "Mercedes-Benz OF 917", status: "Aman" }
];
const DEFAULT_WORK_ORDERS = [
{
id: "WO-9901",
noBody: "TSW 001",
kmMasuk: 45200,
keluhan: "AC kabin belakang berisik & embusan udara kurang dingin mendadak",
prioritas: "Sedang",
status: "Pending",
mekanik: [],
jamMulai: "",
jamSelesai: "",
durasi: 0,
tindakan: "",
sparepart: [],
checker: "Bono",
tanggal: "2026-05-20",
fotoBefore: "https://images.unsplash.com/photo-1544620347-c4fd4a3d5957?w=600&auto=format&fit=crop&q=60",
fotoAfter: ""
},
{
id: "WO-9902",
noBody: "TSW 045",
kmMasuk: 119850,
keluhan: "Pedal kopling keras & ada rembesan oli tipis di dekat transmisi bawah",
prioritas: "Tinggi",
status: "Aktif",
mekanik: ["Alvi", "Wahiyo"],
jamMulai: "2026-05-22T08:00",
jamSelesai: "",
durasi: 0,
tindakan: "",
sparepart: [],
checker: "Andi",
tanggal: "2026-05-22",
fotoBefore: "https://images.unsplash.com/photo-1517524206127-48bbd363f3d7?w=600&auto=format&fit=crop&q=60",
fotoAfter: ""
}
];
const DEFAULT_STORING = [
{
id: "STR-5001",
noBody: "TSW 089",
lokasi: "Halte Busway Pancoran Barat Arah Grogol",
kendala: "Pipa radiator pecah menyebabkan air pendingin habis & mesin overheat",
tindakan: "Potong bagian pipa bocor, pasang clamp bypass darurat, isi air coolant penuh",
mekanik: ["Obi", "Khabul"],
jamMulai: "2026-05-22T14:00",
jamSelesai: "2026-05-22T15:30",
status: "Selesai",
foto: "https://images.unsplash.com/photo-1511919884226-fd3cad34687c?w=600&auto=format&fit=crop&q=60"
},
{
id: "STR-5002",
noBody: "TSW 100",
lokasi: "Samping Gerbang Tol Kebon Jeruk 1",
kendala: "Angin kompresor drop tiba-tiba, rem mengunci otomatis di lajur kiri",
tindakan: "",
mekanik: ["Alvi", "Topik"],
jamMulai: "2026-05-24T09:00",
jamSelesai: "",
status: "OTW",
foto: ""
}
];
const DEFAULT_TIRE_SWAPS = [
{
id: "BAN-8001",
noBody: "TSW 001",
posisi: "Roda Depan Kanan",
kmBan: 85200,
barcodeLama: "BC-SL-8812A",
barcodeBaru: "BC-SN-9905X",
mekanik: ["Wahiyo", "Apri"],
jamMulai: "2026-05-18T10:00",
jamSelesai: "2026-05-18T11:15",
fotoLama: "https://images.unsplash.com/photo-1578844251758-2f71da64c96f?w=400",
fotoBaru: "https://images.unsplash.com/photo-1580273916550-e323be2ae537?w=400"
}
];
export default function App() {
const [isLoggedIn, setIsLoggedIn] = useState(() => {
return localStorage.getItem('tj_logged_in') === 'true';
});
const [currentRole, setCurrentRole] = useState(() => {
return localStorage.getItem('tj_workshop_role') || 'checker';
});
const [currentUserName, setCurrentUserName] = useState(() => {
return localStorage.getItem('tj_user_name') || '';
});
const [workOrders, setWorkOrders] = useState(() => {
const saved = localStorage.getItem('tj_db_work_orders');
return saved ? JSON.parse(saved) : DEFAULT_WORK_ORDERS;
});
const [spareparts, setSpareparts] = useState(() => {
const saved = localStorage.getItem('tj_db_spareparts');
return saved ? JSON.parse(saved) : DEFAULT_SPAREPARTS;
});
const [vehiclesPM, setVehiclesPM] = useState(() => {
const saved = localStorage.getItem('tj_db_vehicles_pm');
return saved ? JSON.parse(saved) : DEFAULT_VEHICLES_PM;
});
const [storingList, setStoringList] = useState(() => {
const saved = localStorage.getItem('tj_db_storing_list');
return saved ? JSON.parse(saved) : DEFAULT_STORING;
});
const [tireSwaps, setTireSwaps] = useState(() => {
const saved = localStorage.getItem('tj_db_tire_swaps');
return saved ? JSON.parse(saved) : DEFAULT_TIRE_SWAPS;
});
const [inspectionLogs, setInspectionLogs] = useState(() => {
const saved = localStorage.getItem('tj_db_inspection_logs');
return saved ? JSON.parse(saved) : [];
});
const [spreadsheetUrl, setSpreadsheetUrl] = useState(() => {
return localStorage.getItem('tj_sheet_webhook') || '';
});
const [activeTab, setActiveTab] = useState('dashboard');
const [mechanicSubTab, setMechanicSubTab] = useState('tugas');
const [toasts, setToasts] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [showConfirmReset, setShowConfirmReset] = useState(false);
const [leaderAssignWO, setLeaderAssignWO] = useState(null);
const [assignedMeks, setAssignedMeks] = useState([]);
const [leaderNotes, setLeaderNotes] = useState('');
const [estimatedDuration, setEstimatedDuration] = useState('4');
const [tempBase64Image, setTempBase64Image] = useState('');
const [tempBase64ImageAfter, setTempBase64ImageAfter] = useState('');
const [newPartForm, setNewPartForm] = useState({ kode: '', nama: '', qty: '', unit: 'Pcs', minQty: '3' });
const [isEditingPart, setIsEditingPart] = useState(null);
const [showAddPartModal, setShowAddPartModal] = useState(false);
const [zoomImgUrl, setZoomImgUrl] = useState(null);
useEffect(() => {
localStorage.setItem('tj_logged_in', isLoggedIn);
localStorage.setItem('tj_workshop_role', currentRole);
localStorage.setItem('tj_user_name', currentUserName);
}, [isLoggedIn, currentRole, currentUserName]);
useEffect(() => {
localStorage.setItem('tj_db_work_orders', JSON.stringify(workOrders));
}, [workOrders]);
useEffect(() => {
localStorage.setItem('tj_db_spareparts', JSON.stringify(spareparts));
}, [spareparts]);
useEffect(() => {
localStorage.setItem('tj_db_vehicles_pm', JSON.stringify(vehiclesPM));
}, [vehiclesPM]);
useEffect(() => {
localStorage.setItem('tj_db_storing_list', JSON.stringify(storingList));
}, [storingList]);
useEffect(() => {
localStorage.setItem('tj_db_tire_swaps', JSON.stringify(tireSwaps));
}, [tireSwaps]);
useEffect(() => {
localStorage.setItem('tj_db_inspection_logs', JSON.stringify(inspectionLogs));
}, [inspectionLogs]);
useEffect(() => {
localStorage.setItem('tj_sheet_webhook', spreadsheetUrl);
}, [spreadsheetUrl]);
const triggerToast = (msg, type = 'success') => {
const id = Date.now();
setToasts(prev => [...prev, { id, msg, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 4000);
};
const removeToast = (id) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
const sendToGoogleSheets = async (sheetType, payloadData) => {
if (!spreadsheetUrl) return;
try {
const response = await fetch(spreadsheetUrl, {
method: "POST",
mode: "no-cors",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sheet: sheetType,
data: payloadData
})
});
triggerToast("📡 Sinkronisasi Google Sheets berhasil berjalan di latar belakang!", "success");
} catch (err) {
console.warn("Spreadsheet dispatch failure", err);
}
};
const handleFileChange = (e, setTargetState) => {
const file = e.target.files[0];
if (file) {
if (file.size > 1.5 * 1024 * 1024) {
triggerToast("Ukuran berkas terlalu besar! Maksimal 1.5MB.", "error");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setTargetState(reader.result);
triggerToast("Foto berhasil dimuat!", "success");
};
reader.readAsDataURL(file);
}
};
const handleLogin = (role, name) => {
if (!name) {
triggerToast("Pilih nama Anda sebelum masuk!", "error");
return;
}
setCurrentRole(role);
setCurrentUserName(name);
setIsLoggedIn(true);
if (role === 'checker') setActiveTab('checker_form');
else if (role === 'mechanic') setActiveTab('mechanic_tasks');
else setActiveTab('dashboard');
triggerToast(`Selamat datang kembali, ${name}! (${role.toUpperCase()})`, "success");
};
const handleLogout = () => {
setIsLoggedIn(false);
setCurrentUserName('');
triggerToast("Anda berhasil keluar dari sistem.", "warning");
};
const stats = useMemo(() => {
const total = workOrders.length;
const pending = workOrders.filter(w => w.status === 'Pending').length;
const aktif = workOrders.filter(w => w.status === 'Aktif').length;
const waiting = workOrders.filter(w => w.status === 'Menunggu Approval').length;
const approved = workOrders.filter(w => w.status === 'Approved').length;
const rejected = workOrders.filter(w => w.status === 'Rejected').length;
const performance = MECHANICS_LIST.map(name => {
const assignedJobs = workOrders.filter(w => w.mekanik.includes(name));
const completedJobs = assignedJobs.filter(w => w.status === 'Approved');
let totalTime = 0;
completedJobs.forEach(j => {
if (j.durasi) totalTime += j.durasi;
});
const avgDur = completedJobs.length > 0 ? Math.round(totalTime / completedJobs.length) : 0;
const totalStoring = storingList.filter(s => s.mekanik.includes(name) && s.status === 'Selesai').length;
const totalTire = tireSwaps.filter(t => t.mekanik.includes(name)).length;
const score = (completedJobs.length * 15) + (totalStoring * 20) + (totalTire * 10);
return {
name,
assignedCount: assignedJobs.length,
completedCount: completedJobs.length,
avgDur,
storingCount: totalStoring,
tireCount: totalTire,
score
};
}).sort((a, b) => b.score - a.score);
const lowStockParts = spareparts.filter(s => s.qty <= s.minQty);
const overduePM = vehiclesPM.filter(v => v.currentKM >= v.nextPMTarget).length;
const nearPM = vehiclesPM.filter(v => v.currentKM < v.nextPMTarget && (v.nextPMTarget - v.currentKM) <= 2000).length;
return {
total,
pending,
aktif,
waiting,
approved,
rejected,
performance,
lowStockCount: lowStockParts.length,
lowStockParts,
overduePM,
nearPM
};
}, [workOrders, spareparts, storingList, tireSwaps, vehiclesPM]);
const handleCreateWO = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const noBody = formData.get('noBody').toUpperCase().trim();
const kmMasuk = parseInt(formData.get('kmMasuk'));
const keluhan = (formData.get('keluhan') || '').trim();
const prioritas = formData.get('prioritas');
if (!noBody || !kmMasuk) {
triggerToast("Harap isi No Body dan Odometer wajib!", "error");
return;
}
const hasFinding = keluhan.length > 0;
const newLog = {
id: `INSP-${Date.now().toString().slice(-4)}`,
noBody,
kmMasuk,
keluhan: hasFinding ? keluhan : "Tidak ada keluhan (Kondisi Unit Normal / Prima)",
prioritas: hasFinding ? prioritas : "Rendah",
checker: currentUserName,
tanggal: new Date().toISOString().split('T')[0],
status: hasFinding ? "Temuan Kerusakan" : "Armada Prima / Siap Dinas",
fotoBefore: tempBase64Image || "https://images.unsplash.com/photo-1544620347-c4fd4a3d5957?w=600&auto=format&fit=crop&q=60"
};
setInspectionLogs(prev => [newLog, ...prev]);
sendToGoogleSheets("inspeksi", newLog);
setVehiclesPM(prev => {
const exists = prev.find(v => v.noBody === noBody);
if (exists) {
return prev.map(v => v.noBody === noBody ? { ...v, currentKM: kmMasuk } : v);
} else {
return [...prev, {
noBody,
currentKM: kmMasuk,
nextPMTarget: Math.ceil((kmMasuk + 1) / 10000) * 10000,
model: "Bus Transjakarta Hino/Scania",
status: "Aman"
}];
}
});
if (hasFinding) {
const newWO = {
id: `WO-${Date.now().toString().slice(-4)}`,
noBody,
kmMasuk,
keluhan,
prioritas,
status: "Pending",
mekanik: [],
jamMulai: "",
jamSelesai: "",
durasi: 0,
tindakan: "",
sparepart: [],
checker: currentUserName,
tanggal: new Date().toISOString().split('T')[0],
fotoBefore: tempBase64Image || "https://images.unsplash.com/photo-1544620347-c4fd4a3d5957?w=600&auto=format&fit=crop&q=60",
fotoAfter: ""
};
setWorkOrders(prev => [newWO, ...prev]);
sendToGoogleSheets("work_order", newWO);
triggerToast(`Temuan kerusakan terdaftar! WO ${newWO.id} dikirim ke Leader.`, "success");
} else {
triggerToast(`Inspeksi berhasil! Armada ${noBody} dalam kondisi PRIMA (Siap Jalan).`, "success");
}
setTempBase64Image('');
e.target.reset();
};
const handleSelfReportSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const noBody = formData.get('noBody').toUpperCase().trim();
const keluhan = formData.get('keluhan').trim();
const kmMasuk = parseInt(formData.get('kmMasuk') || '0');
const partCode = formData.get('partCode');
const partQty = parseInt(formData.get('partQty') || '0');
if (!noBody || !keluhan) {
triggerToast("Mohon isi nomor armada dan detail temuan!", "error");
return;
}
let partsUsed = [];
if (partCode && partQty > 0) {
const targetPart = spareparts.find(p => p.kode === partCode);
if (targetPart) {
if (targetPart.qty < partQty) {
triggerToast(`Stok ${targetPart.nama} tidak cukup! (Tersedia: ${targetPart.qty})`, "error");
return;
}
partsUsed.push({ kode: partCode, nama: targetPart.nama, qty: partQty });
const updatedPart = { ...targetPart, qty: targetPart.qty - partQty };
setSpareparts(prev => prev.map(p => p.kode === partCode ? updatedPart : p));
// Update stok sparepart di Google Sheets
sendToGoogleSheets("spareparts", updatedPart);
}
}
const newWO = {
id: `WO-M-${Date.now().toString().slice(-4)}`,
noBody,
kmMasuk,
keluhan: `[TEMUAN SPONTAN] ${keluhan}`,
prioritas: "Sedang",
status: "Menunggu Approval",
mekanik: [currentUserName],
jamMulai: new Date().toISOString().slice(0, 16),
jamSelesai: new Date().toISOString().slice(0, 16),
durasi: 30,
tindakan: keluhan,
sparepart: partsUsed,
checker: currentUserName,
tanggal: new Date().toISOString().split('T')[0],
fotoBefore: tempBase64Image || "",
fotoAfter: tempBase64Image || ""
};
setWorkOrders(prev => [newWO, ...prev]);
sendToGoogleSheets("work_order", newWO);
setTempBase64Image('');
triggerToast(`Laporan Mandiri ${newWO.id} dikirim ke Leader! Stok terpotong.`, "success");
e.target.reset();
setMechanicSubTab('tugas');
};
const handleAssignWO = () => {
if (assignedMeks.length === 0) {
triggerToast("Silakan tunjuk minimal satu mekanik!", "error");
return;
}
const targetWO = workOrders.find(w => w.id === leaderAssignWO.id);
if (targetWO) {
const updatedWO = {
...targetWO,
status: "Aktif",
mekanik: assignedMeks,
jamMulai: new Date().toISOString().slice(0, 16),
leaderNotes: leaderNotes || "Lakukan perbaikan dan cek sensor terkait."
};
setWorkOrders(prev => prev.map(w => w.id === leaderAssignWO.id ? updatedWO : w));
sendToGoogleSheets("work_order", updatedWO);
}
triggerToast(`WO ${leaderAssignWO.id} ditugaskan ke: ${assignedMeks.join(', ')}`, "success");
setLeaderAssignWO(null);
setAssignedMeks([]);
setLeaderNotes('');
};
const handleRejectWO = (woId) => {
const targetWO = workOrders.find(w => w.id === woId);
if (targetWO) {
const rejectedWO = { ...targetWO, status: "Rejected" };
setWorkOrders(prev => prev.map(w => w.id === woId ? rejectedWO : w));
sendToGoogleSheets("work_order", rejectedWO);
}
triggerToast(`Work Order ${woId} ditolak oleh Leader.`, "warning");
};
const handleCompleteRepair = (e, woId) => {
e.preventDefault();
const formData = new FormData(e.target);
const tindakan = formData.get('tindakan').trim();
const partCode = formData.get('partCode');
const partQty = parseInt(formData.get('partQty') || '0');
if (!tindakan) {
triggerToast("Mohon isi deskripsi tindakan perbaikan!", "error");
return;
}
let partsUsed = [];
if (partCode && partQty > 0) {
const targetPart = spareparts.find(p => p.kode === partCode);
if (targetPart) {
if (targetPart.qty < partQty) {
triggerToast(`Stok ${targetPart.nama} tidak cukup! (Tersedia: ${targetPart.qty})`, "error");
return;
}
partsUsed.push({
kode: partCode,
nama: targetPart.nama,
qty: partQty
});
const updatedPart = { ...targetPart, qty: targetPart.qty - partQty };
setSpareparts(prev => prev.map(p => p.kode === partCode ? updatedPart : p));
// Update stok spareparts di Google Sheets
sendToGoogleSheets("spareparts", updatedPart);
}
}
const targetWO = workOrders.find(w => w.id === woId);
if (targetWO) {
const start = targetWO.jamMulai ? new Date(targetWO.jamMulai) : new Date();
const end = new Date();
const diffMin = Math.max(15, Math.round((end - start) / 60000));
const completedWO = {
...targetWO,
status: "Menunggu Approval",
jamSelesai: end.toISOString().slice(0, 16),
durasi: diffMin,
tindakan,
sparepart: partsUsed,
fotoAfter: tempBase64ImageAfter || "https://images.unsplash.com/photo-1563720223185-11003d516935?w=600&auto=format&fit=crop&q=60"
};
setWorkOrders(prev => prev.map(w => w.id === woId ? completedWO : w));
sendToGoogleSheets("work_order", completedWO);
}
setTempBase64ImageAfter('');
triggerToast(`WO ${woId} selesai diperbaiki! Menunggu persetujuan Leader Mekanik.`, "success");
};
const handleApproveCompletion = (woId) => {
const targetWO = workOrders.find(w => w.id === woId);
if (targetWO) {
const approvedWO = { ...targetWO, status: "Approved" };
setWorkOrders(prev => prev.map(w => w.id === woId ? approvedWO : w));
sendToGoogleSheets("work_order", approvedWO);
}
triggerToast(`WO ${woId} disetujui, bus siap kembali berdinas!`, "success");
};
const handleResetPMInterval = (noBody) => {
setVehiclesPM(prev => prev.map(v => {
if (v.noBody === noBody) {
const nextTarget = Math.ceil((v.currentKM + 1) / 10000) * 10000 + 10000;
triggerToast(`PM Armada ${noBody} direset ke target baru: ${nextTarget.toLocaleString()} KM`, "success");
return { ...v, nextPMTarget: nextTarget };
}
return v;
}));
};
const handleAddStoringByLeader = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const noBody = formData.get('noBody').toUpperCase().trim();
const lokasi = formData.get('lokasi').trim();
const kendala = formData.get('kendala').trim();
const selectedMeks = assignedMeks;
if (!noBody || !lokasi || !kendala || selectedMeks.length === 0) {
triggerToast("Mohon lengkapi data dispatch rescue!", "error");
return;
}
const newStoring = {
id: `STR-${Date.now().toString().slice(-4)}`,
noBody,
lokasi,
kendala,
tindakan: "",
mekanik: selectedMeks,
jamMulai: new Date().toISOString().slice(0, 16),
jamSelesai: "",
status: "OTW",
foto: ""
};
setStoringList(prev => [newStoring, ...prev]);
sendToGoogleSheets("storing", newStoring);
setAssignedMeks([]);
triggerToast(`Tim Rescue ${selectedMeks.join(', ')} resmi diberangkatkan ke ${lokasi}!`, "success");
e.target.reset();
};
const handleCompleteStoringByMechanic = (e, storingId) => {
e.preventDefault();
const formData = new FormData(e.target);
const tindakan = formData.get('tindakan').trim();
if (!tindakan) {
triggerToast("Harap ketik rincian tindakan emergency yang Anda lakukan!", "error");
return;
}
const targetStoring = storingList.find(s => s.id === storingId);
if (targetStoring) {
const completedStoring = {
...targetStoring,
tindakan,
status: "Selesai",
jamSelesai: new Date().toISOString().slice(0, 16),
foto: tempBase64Image || "https://images.unsplash.com/photo-1511919884226-fd3cad34687c?w=600&auto=format&fit=crop&q=60"
};
setStoringList(prev => prev.map(s => s.id === storingId ? completedStoring : s));
sendToGoogleSheets("storing", completedStoring);
}
setTempBase64Image('');
triggerToast(`Laporan Rescue ${storingId} berhasil diselesaikan di lokasi!`, "success");
};
const handleAddTireSwap = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const noBody = formData.get('noBody').toUpperCase().trim();
const posisi = formData.get('posisi');
const kmBan = parseInt(formData.get('kmBan') || '0');
const barcodeLama = formData.get('barcodeLama').trim();
const barcodeBaru = formData.get('barcodeBaru').trim();
const selectedMeks = assignedMeks;
if (!noBody || !posisi || !kmBan || !barcodeLama || !barcodeBaru || selectedMeks.length === 0) {
triggerToast("Data penggantian ban tidak lengkap!", "error");
return;
}
const tireCode = "SP-TIRE-295";
const tirePart = spareparts.find(p => p.kode === tireCode);
if (tirePart && tirePart.qty <= 0) {
triggerToast("Gagal! Stok Ban Radial Tubeless di gudang saat ini habis.", "error");
return;
}
const updatedTire = { ...tirePart, qty: Math.max(0, tirePart.qty - 1) };
setSpareparts(prev => prev.map(p => {
if (p.kode === tireCode) {
return updatedTire;
}
return p;
}));
// Sinkronisasi sisa ban radial ke Google Sheets
sendToGoogleSheets("spareparts", updatedTire);
const newSwap = {
id: `BAN-${Date.now().toString().slice(-4)}`,
noBody,
posisi,
kmBan,
barcodeLama,
barcodeBaru,
mekanik: selectedMeks,
jamMulai: new Date().toISOString().slice(0, 16),
jamSelesai: new Date().toISOString().slice(0, 16),
fotoLama: tempBase64Image || "https://images.unsplash.com/photo-1578844251758-2f71da64c96f?w=400",
fotoBaru: tempBase64ImageAfter || "https://images.unsplash.com/photo-1580273916550-e323be2ae537?w=400"
};
setTireSwaps(prev => [newSwap, ...prev]);
sendToGoogleSheets("ganti_ban", newSwap);
setTempBase64Image('');
setTempBase64ImageAfter('');
setAssignedMeks([]);
triggerToast(`Data ganti ban berhasil disimpan. Stok Ban Radial gudang terpotong 1 Pcs.`, "success");
e.target.reset();
};
const handleSavePart = (e) => {
e.preventDefault();
if (!newPartForm.kode || !newPartForm.nama || newPartForm.qty === '') {
triggerToast("Kode, Nama, dan Kuantitas Suku Cadang wajib diisi!", "error");
return;
}
const codeUpper = newPartForm.kode.toUpperCase().trim();
const qtyInt = parseInt(newPartForm.qty);
const minQtyInt = parseInt(newPartForm.minQty || '3');
const existsIndex = spareparts.findIndex(p => p.kode === codeUpper);
const updatedPart = {
kode: codeUpper,
nama: newPartForm.nama,
qty: qtyInt,
unit: newPartForm.unit,
minQty: minQtyInt
};
if (existsIndex >= 0 && isEditingPart !== 'new') {
setSpareparts(prev => prev.map((p, idx) => idx === existsIndex ? updatedPart : p));
triggerToast(`Data Suku Cadang ${codeUpper} diperbarui!`, "success");
// Kirim pembaruan suku cadang ke Google Sheets (Update Baris)
sendToGoogleSheets("spareparts", updatedPart);
} else {
if (spareparts.find(p => p.kode === codeUpper)) {
triggerToast("Kode suku cadang sudah digunakan!", "error");
return;
}
setSpareparts(prev => [...prev, updatedPart]);
triggerToast(`Suku cadang baru ${codeUpper} didaftarkan!`, "success");
// Kirim suku cadang baru ke Google Sheets (Tambah Baris)
sendToGoogleSheets("spareparts", updatedPart);
}
setNewPartForm({ kode: '', nama: '', qty: '', unit: 'Pcs', minQty: '3' });
setIsEditingPart(null);
setShowAddPartModal(false);
};
const handleRemovePart = (code) => {
setSpareparts(prev => prev.filter(p => p.kode !== code));
triggerToast(`Suku cadang ${code} dihapus.`, "warning");
};
const triggerResetSystem = () => {
setWorkOrders(DEFAULT_WORK_ORDERS);
setSpareparts(DEFAULT_SPAREPARTS);
setVehiclesPM(DEFAULT_VEHICLES_PM);
setStoringList(DEFAULT_STORING);
setTireSwaps(DEFAULT_TIRE_SWAPS);
setInspectionLogs([]);
triggerToast("Sistem bengkel berhasil di-reset ke setelan awal.", "warning");
setShowConfirmReset(false);
};
const filteredWorkOrders = useMemo(() => {
return workOrders.filter(w => {
const matchText = `${w.id} ${w.noBody} ${w.keluhan} ${w.prioritas} ${w.status} ${w.checker}`.toLowerCase();
return matchText.includes(searchQuery.toLowerCase());
});
}, [workOrders, searchQuery]);
const myMechanicJobs = useMemo(() => {
return workOrders.filter(w => w.mekanik.includes(currentUserName) && w.status === 'Aktif');
}, [workOrders, currentUserName]);
const myMechanicStoringJobs = useMemo(() => {
return storingList.filter(s => s.mekanik.includes(currentUserName) && s.status === 'OTW');
}, [storingList, currentUserName]);
const handleExportCSV = (type) => {
let headers = [];
let rows = [];
let filename = `laporan_${type}_${Date.now()}.csv`;
const cleanCell = (val) => {
if (val === undefined || val === null) return '""';
let str = String(val);
str = str.replace(/\r?\n|\r/g, ' ');
str = str.replace(/"/g, '""');
return `"${str}"`;
};
if (type === 'work_orders') {
headers = ['ID WO', 'No Body', 'KM Masuk', 'Keluhan', 'Prioritas', 'Status', 'Mekanik', 'Tanggal', 'Tindakan', 'Checker'];
rows = workOrders.map(w => [
cleanCell(w.id),
cleanCell(w.noBody),
cleanCell(w.kmMasuk),
cleanCell(w.keluhan),
cleanCell(w.prioritas),
cleanCell(w.status),
cleanCell(w.mekanik.join(', ')),
cleanCell(w.tanggal),
cleanCell(w.tindakan || 'Belum ada'),
cleanCell(w.checker)
]);
} else {
headers = ['Kode', 'Nama Suku Cadang', 'Jumlah Stok', 'Satuan', 'Batas Minimum'];
rows = spareparts.map(p => [
cleanCell(p.kode),
cleanCell(p.nama),
cleanCell(p.qty),
cleanCell(p.unit),
cleanCell(p.minQty)
]);
}
const delimiter = ";";
const csvContent = "\uFEFF"
+ `sep=${delimiter}\n`
+ headers.join(delimiter) + "\n"
+ rows.map(r => r.join(delimiter)).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
triggerToast(`Ekspor ${filename} sukses! Format rapi & terbagi kolom Excel.`, "success");
};
if (!isLoggedIn) {
return (
{toasts.map(t => (
{t.type === 'success' && }
{t.type === 'warning' && }
{t.type === 'error' && }
removeToast(t.id)} className="text-slate-400 hover:text-white ml-4 font-bold text-lg leading-none">×
))}
BENGKEL TRANSJAKARTA
Sistem Perawatan Bus & Integrasi Depo
);
}
return (
{/* 2. DATALIST UNTUK AUTOCOMPLETE NOMOR BODY BUS */}
{FLEET_UNITS.map(unit => (
))}
{toasts.map(t => (
{t.type === 'success' && }
{t.type === 'warning' && }
{t.type === 'error' && }
removeToast(t.id)} className="text-slate-400 hover:text-white ml-4 font-bold text-lg leading-none">×
))}
MENU PERAN DINAS
{(currentRole === 'admin' || currentRole === 'leader') && (
setActiveTab('dashboard')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'dashboard' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Analitik Utama
)}
{currentRole === 'checker' && (
<>
setActiveTab('checker_form')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'checker_form' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Inspeksi Armada Baru
setActiveTab('checker_history')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'checker_history' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Log Inspeksi Saya
>
)}
{currentRole === 'mechanic' && (
setActiveTab('mechanic_tasks')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'mechanic_tasks' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Pekerjaan Aktif Anda
{myMechanicJobs.length + myMechanicStoringJobs.length}
)}
{currentRole !== 'checker' && (
<>
MODUL UTAMA DEPO
setActiveTab('workorders')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'workorders' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Semua Work Orders
{workOrders.length}
setActiveTab('preventive')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'preventive' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Monitoring PM 10K
{stats.overduePM > 0 && (
{stats.overduePM} Alert
)}
setActiveTab('storing')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'storing' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Storing / Rescue Jalan
setActiveTab('tires')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'tires' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Penggantian Ban Bus
setActiveTab('gudang')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === 'gudang' ? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20' : 'text-slate-400 hover:bg-slate-850 hover:text-white'
}`}
>
Gudang Spareparts
{stats.lowStockCount > 0 && (
{stats.lowStockCount} Limit
)}
{(currentRole === 'admin' || currentRole === 'leader') && (
setActiveTab('google_sheets')}
className={`w-full flex items-center justify-between p-3 rounded-xl text-sm font-semibold transition-all border-dashed border ${
activeTab === 'google_sheets'
? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/40'
: 'border-slate-800 text-slate-400 hover:bg-slate-855 hover:text-white'
}`}
>
Integrasi Sheets
{spreadsheetUrl ? (
Aktif
) : (
Off
)}
)}
>
)}
{currentRole === 'admin' && (
setShowConfirmReset(true)}
className="w-full flex items-center justify-center gap-2 p-2 bg-rose-955/20 border border-rose-500/25 rounded-xl text-xs font-bold text-rose-400 hover:bg-rose-955/40 transition-all"
>
Reset Data Sistem
)}
{/* ================================================== */}
{/* TAB: ANALITIK UTAMA (DASHBOARD) */}
{/* ================================================== */}
{activeTab === 'dashboard' && (currentRole === 'admin' || currentRole === 'leader') && (
Analitik & Dashboard Pemantauan Depo
Status real-time armada bus, ketersediaan suku cadang, dan performa mekanik.
handleExportCSV('work_orders')}
className="bg-slate-900 hover:bg-slate-850 text-slate-300 border border-slate-800 font-bold px-3.5 py-2 rounded-xl text-xs flex items-center gap-1.5 transition-all"
>
Ekspor WO (CSV)
handleExportCSV('spareparts')}
className="bg-slate-900 hover:bg-slate-850 text-slate-300 border border-slate-800 font-bold px-3.5 py-2 rounded-xl text-xs flex items-center gap-1.5 transition-all"
>
Ekspor Gudang (CSV)
{/* KPI Cards */}
Antrean WO Pending
{stats.pending}
Butuh Tindakan
{stats.aktif}
Mekanik Tiket
{stats.overduePM}
Armada Alert
{stats.lowStockCount}
Kritis
{/* Main Dashboard Rows */}
{/* Mechanic Leaderboard */}
Papan Performa Mekanik Shift Ini
Nama Mekanik
Tugas Aktif
WO Sukses
Storing/Rescue
Ganti Ban
Skor Kerja
{stats.performance.slice(0, 7).map((p, idx) => (
#{idx+1}
{p.name}
{p.assignedCount}
{p.completedCount}
{p.storingCount}
{p.tireCount}
{p.score}
))}
{/* Overdue/Near PM List */}
Peringatan Servis Berkala Mendesak
{vehiclesPM.filter(v => v.currentKM >= v.nextPMTarget || (v.nextPMTarget - v.currentKM) <= 2000).map(v => {
const sisa = v.nextPMTarget - v.currentKM;
const isOverdue = sisa <= 0;
return (
Armada {v.noBody} • {v.model}
Odo: {v.currentKM.toLocaleString()} / Target: {v.nextPMTarget.toLocaleString()} KM
{isOverdue ? `Lewat ${Math.abs(sisa).toLocaleString()} KM` : `Sisa ${sisa.toLocaleString()} KM`}
STATUS SERVIS
);
})}
{vehiclesPM.filter(v => v.currentKM >= v.nextPMTarget || (v.nextPMTarget - v.currentKM) <= 2000).length === 0 && (
Semua unit bus dalam siklus preventive maintenance yang aman.
)}
{/* Visual WO Distribution */}
Distribusi Status Work Order
Antrean Menunggu Tindakan (Pending)
{stats.pending} WO
0 ? (stats.pending / stats.total) * 100 : 0}%` }}>
Sedang Dikerjakan Mekanik (Aktif)
{stats.aktif} WO
0 ? (stats.aktif / stats.total) * 100 : 0}%` }}>
Menunggu Approval Selesai Kerja
{stats.waiting} WO
0 ? (stats.waiting / stats.total) * 100 : 0}%` }}>
Pekerjaan Selesai & Disetujui (Approved)
{stats.approved} WO
0 ? (stats.approved / stats.total) * 100 : 0}%` }}>
{/* Suku Cadang Kritis */}
Suku Cadang di Bawah Batas Minimum
{stats.lowStockParts.map(p => (
{p.nama}
{p.kode}
{p.qty} sisa
Safety Limit: {p.minQty}
))}
{stats.lowStockCount === 0 && (
Ketersediaan logistik suku cadang aman terkendali.
)}
)}
{/* ================================================== */}
{/* TAB: INPUT INSPEKSI BARU (CHECKER ONLY) */}
{/* ================================================== */}
{activeTab === 'checker_form' && currentRole === 'checker' && (
Formulir Inspeksi Harian Armada
Pemeriksa: {currentUserName} • Catat kondisi aktual dan odometer bus sebelum beroperasi.
)}
{/* ================================================== */}
{/* TAB: LOG INSPEKSI SAYA (CHECKER ONLY) */}
{/* ================================================== */}
{activeTab === 'checker_history' && currentRole === 'checker' && (
Log Hasil Inspeksi Saya
Daftar riwayat pemeriksaan armada bus yang telah Anda input.
{inspectionLogs.length === 0 ? (
Belum ada riwayat inspeksi yang dicatat hari ini.
) : (
inspectionLogs.filter(log => log.checker === currentUserName).map(log => (
{log.id}
Armada {log.noBody}
{log.status}
Odometer / KM:
{log.kmMasuk.toLocaleString()} KM
Temuan Kerusakan:
“{log.keluhan}”
Skala Prioritas:
{log.prioritas}
{log.fotoBefore && (
Foto Bukti Fisik:
setZoomImgUrl(log.fotoBefore)}
className="w-full h-24 object-cover rounded-xl border border-slate-800 cursor-zoom-in hover:opacity-90"
/>
)}
))
)}
)}
{/* ================================================== */}
{/* TAB: PEKERJAAN AKTIF MEKANIK (MECHANIC ONLY) */}
{/* ================================================== */}
{activeTab === 'mechanic_tasks' && currentRole === 'mechanic' && (
Workspace Perbaikan Mekanik: {currentUserName}
Shift Aktif Anda. Selesaikan tugas bengkel & emergency storing di bawah ini.
{/* NAVIGASI SUB-TAB MEKANIK */}
setMechanicSubTab('tugas')}
className={`flex-1 py-3 px-4 rounded-lg font-bold text-sm flex items-center justify-center gap-2 transition-all ${
mechanicSubTab === 'tugas'
? 'bg-emerald-500 text-slate-950 shadow-lg'
: 'text-slate-400 hover:text-white hover:bg-slate-850'
}`}
>
Daftar Tugas Aktif ({myMechanicJobs.length + myMechanicStoringJobs.length})
setMechanicSubTab('lapor')}
className={`flex-1 py-3 px-4 rounded-lg font-bold text-sm flex items-center justify-center gap-2 transition-all ${
mechanicSubTab === 'lapor'
? 'bg-cyan-500 text-slate-950 shadow-lg'
: 'text-slate-400 hover:text-white hover:bg-slate-850'
}`}
>
Lapor Temuan Mandiri (Ad-hoc)
{mechanicSubTab === 'tugas' ? (
<>
{/* EMERGENCY ROAD RESCUE UNTUK MEKANIK */}
{myMechanicStoringJobs.length > 0 && (
🚨 TUGAS EMERGENCY ROAD SERVICE (STORING JALAN)
{myMechanicStoringJobs.map(st => (
DITUGASKAN - OTW TKP
{st.id} • Bus {st.noBody}
Waktu Keluar: {st.jamMulai.replace('T', ' ')}
LOKASI DARURAT:
{st.lokasi}
KENDALA AWAL DILAPORKAN PRAMUDI:
“{st.kendala}”
handleCompleteStoringByMechanic(e, st.id)} className="bg-slate-955/60 p-4 rounded-xl border border-slate-850 space-y-3.5">
Laporan Penyelesaian TKP (Isi Saat Masalah Teratasi)
Tindakan Penanganan Emergency Teknis
Foto Hasil Perbaikan Lapangan (Wajib)
Selesaikan Storing
))}
)}
{/* TUGAS WORKSHOP BIASA MEKANIK */}
TUGAS PERBAIKAN DI BAY BENGKEL
{myMechanicJobs.map(wo => (
Identitas Tiket
{wo.id} • Body {wo.noBody}
KM Masuk: {wo.kmMasuk.toLocaleString()} KM
Aduan / Temuan
“{wo.keluhan}”
{wo.fotoBefore && (
Visual Damage
setZoomImgUrl(wo.fotoBefore)}
className="w-full h-32 object-cover rounded-xl border border-slate-800 cursor-zoom-in hover:opacity-90"
/>
)}
Instruksi Leader
{wo.leaderNotes || "Lakukan penelusuran menyeluruh."}
Status Suku Cadang
Gunakan form di sebelah kanan jika memerlukan penggantian bagian.
Form Selesai Kerja
handleCompleteRepair(e, wo.id)} className="space-y-3">
Tindakan Teknis Yang Dilakukan
Foto Hasil Perbaikan (Setelah)
Kirim & Minta Verifikasi Selesai
))}
{myMechanicJobs.length === 0 && myMechanicStoringJobs.length === 0 && (
Tidak ada pekerjaan aktif yang ditugaskan kepada Anda saat ini.
)}
>
) : (
Catat Temuan Kerusakan Spontan
Formulir laporan ini akan menghasilkan WO berawalan WO-M-xxxx and otomatis memotong stok gudang jika ada komponen yang digunakan.
Detail Temuan & Tindakan Perbaikan
Pemakaian Suku Cadang Gudang
Kirim Laporan Spontan ke Leader (Pending Approval)
)}
)}
{/* ================================================== */}
{/* TAB: SEMUA WORK ORDERS */}
{/* ================================================== */}
{activeTab === 'workorders' && currentRole !== 'checker' && (
{filteredWorkOrders.map(wo => (
{wo.id}
Body: {wo.noBody}
|
KM: {wo.kmMasuk.toLocaleString()}
Prioritas {wo.prioritas}
{wo.status}
Aduan Masuk
“{wo.keluhan}”
Checker: {wo.checker} • {wo.tanggal}
{wo.fotoBefore && (
Foto Kerusakan (Before)
setZoomImgUrl(wo.fotoBefore)}
className="w-full h-24 object-cover rounded-xl border border-slate-800 cursor-zoom-in hover:opacity-90"
/>
)}
Tim Pelaksana
Mekanik: {wo.mekanik.join(', ') || 'Belum Ditunjuk'}
{wo.durasi > 0 &&
Durasi Kerja: {wo.durasi} Menit
}
{wo.leaderNotes &&
Instruksi: {wo.leaderNotes}
}
{wo.tindakan && (
Tindakan Teknis Mekanik:
{wo.tindakan}
)}
Suku Cadang Digunakan
{wo.sparepart.length > 0 ? (
{wo.sparepart.map(p => (
{p.nama}
x{p.qty}
))}
) : (
Tanpa suku cadang tambahan.
)}
{wo.fotoAfter && (
Foto Hasil Kerja (After)
setZoomImgUrl(wo.fotoAfter)}
className="w-full h-24 object-cover rounded-xl border border-slate-800 cursor-zoom-in hover:opacity-90"
/>
)}
{currentRole === 'leader' && wo.status === 'Pending' && (
setLeaderAssignWO(wo)}
className="flex-1 bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-black py-2 rounded-xl text-xs"
>
Approve & Tugaskan
handleRejectWO(wo.id)}
className="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 font-bold py-2 px-3 rounded-xl text-xs"
>
Reject
)}
{currentRole === 'leader' && wo.status === 'Menunggu Approval' && (
handleApproveCompletion(wo.id)}
className="w-full bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-black py-2 rounded-xl text-xs"
>
Setujui Pekerjaan Selesai
)}
))}
)}
{/* ================================================== */}
{/* TAB: MONITORING PM BERKALA 10.000 KM */}
{/* ================================================== */}
{activeTab === 'preventive' && currentRole !== 'checker' && (
Preventive Maintenance (PM) Berkala
Sistem alert otomatis servis berkala setiap interval 10.000 Kilometer armada.
{vehiclesPM.map(v => {
const sisaKM = v.nextPMTarget - v.currentKM;
const ratio = Math.max(0, Math.min(100, ((10000 - sisaKM) / 10000) * 100));
let barColor = "from-emerald-500 to-teal-500";
let borderClass = "border-slate-800 bg-slate-900/60";
let badgeText = "Aman / Safe";
let badgeColor = "bg-emerald-500/10 text-emerald-400 border-emerald-500/20";
if (sisaKM <= 0) {
barColor = "from-red-500 to-rose-600 animate-pulse";
borderClass = "border-red-500/30 bg-red-955/10";
badgeText = "Overdue Servis";
badgeColor = "bg-red-500/20 text-red-400 border-red-500/30 animate-pulse";
} else if (sisaKM <= 2000) {
barColor = "from-amber-500 to-yellow-500";
borderClass = "border-amber-500/25 bg-amber-955/10";
badgeText = "Mendekati PM";
badgeColor = "bg-amber-500/10 text-amber-400 border-amber-500/20";
}
return (
{v.model}
TJ Body {v.noBody}
{badgeText}
Km Sekarang
{v.currentKM.toLocaleString()} KM
Target PM
{v.nextPMTarget.toLocaleString()} KM
Sisa Jarak Tempuh
{sisaKM > 0 ? `${sisaKM.toLocaleString()} KM` : `${Math.abs(sisaKM).toLocaleString()} KM OVERDUE`}
{(currentRole === 'leader' || currentRole === 'admin') && (
handleResetPMInterval(v.noBody)}
className="w-full mt-5 bg-slate-950 hover:bg-slate-900 text-slate-300 font-bold py-2 rounded-xl text-xs flex items-center justify-center gap-2 border border-slate-855 transition-all"
>
Atur Siklus PM Baru (+10.000 KM)
)}
);
})}
)}
{/* ================================================== */}
{/* TAB: STORING JALAN (RESCUE EMERGENCY) */}
{/* ================================================== */}
{activeTab === 'storing' && currentRole !== 'checker' && (
Emergency Road Service (Storing Rescue)
Penanganan kerusakan darurat armada di luar area depo secara real-time.
{/* FORM DISPATCH OLEH LEADER */}
{(currentRole === 'leader' || currentRole === 'admin') ? (
) : (
Modul input penugasan dispatch storing jalan hanya diizinkan untuk Leader dan Admin.
)}
{/* HISTORI STORING JALAN */}
Histori Operasi Penyelamatan Jalan
{storingList.map(s => (
{s.id} • TJ Body {s.noBody}
{s.status === 'Selesai' ? 'RESCUE SUKSES' : 'DALAM DISPATCH OTW'}
LOKASI BREAKDOWN:
{s.lokasi}
KENDALA AWAL DILAPORKAN:
“{s.kendala}”
TINDAKAN RECOVERY (OLEH MEKANIK TKP):
{s.tindakan ? (
{s.tindakan}
) : (
Mekanik masih dalam perjalanan menuju lokasi...
)}
MEKANIK PENYELAMAT:
{s.mekanik.join(', ')}
{s.foto && (
Bukti Foto Lokasi Kejadian:
setZoomImgUrl(s.foto)}
className="w-full h-32 object-cover rounded-xl border border-slate-855 cursor-zoom-in hover:opacity-90"
/>
)}
))}
)}
{/* ================================================== */}
{/* TAB: PENGGANTIAN BAN ARMADA */}
{/* ================================================== */}
{activeTab === 'tires' && currentRole !== 'checker' && (
Log Penggantian Ban Bus Depo
Pendaftaran penggantian roda bus akan otomatis memotong 1 Pcs stok ban radial di gudang.
Histori Log Ban Depo
{tireSwaps.map(t => (
{t.id}
Armada {t.noBody}
Posisi: {t.posisi}
Masa Pakai Odo Ban: {t.kmBan.toLocaleString()} KM
Old: {t.barcodeLama}
New: {t.barcodeBaru}
Swapper Mekanik:
{t.mekanik.join(', ')}
{t.fotoLama && (
setZoomImgUrl(t.fotoLama)}
className="w-8 h-8 object-cover rounded border border-slate-800 mt-2 ml-auto cursor-zoom-in hover:opacity-90"
/>
)}
))}
)}
{/* ================================================== */}
{/* TAB: GUDANG SPAREPART INVENTORY */}
{/* ================================================== */}
{activeTab === 'gudang' && currentRole !== 'checker' && (
Logistik & Gudang Suku Cadang
Kelola inventaris suku cadang, update minimum safety stock, restock instan.
{(currentRole === 'admin' || currentRole === 'leader') && (
{
setNewPartForm({ kode: '', nama: '', qty: '', unit: 'Pcs', minQty: '3' });
setIsEditingPart('new');
setShowAddPartModal(true);
}}
className="bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-extrabold px-4 py-2.5 rounded-xl text-xs flex items-center gap-2"
>
Daftarkan Sparepart Baru
)}
{spareparts.map(p => {
const isLimit = p.qty <= p.minQty;
return (
{p.kode}
{isLimit && (
Stok Kritis
)}
{p.nama}
Minimum Safety: {p.minQty} {p.unit}
{p.qty}
{p.unit}
{(currentRole === 'admin' || currentRole === 'leader') && (
{
setNewPartForm(p);
setIsEditingPart(p.kode);
setShowAddPartModal(true);
}}
className="p-1.5 bg-slate-950 hover:bg-slate-855 rounded-lg text-slate-400 hover:text-white border border-slate-850"
>
{currentRole === 'admin' && (
handleRemovePart(p.kode)}
className="p-1.5 bg-rose-955/30 hover:bg-rose-955/60 rounded-lg text-rose-400 border border-rose-500/15"
>
)}
)}
);
})}
)}
{/* ================================================== */}
{/* TAB: SPREADSHEET SETTINGS & WEBHOOK */}
{/* ================================================== */}
{activeTab === 'google_sheets' && (currentRole === 'admin' || currentRole === 'leader') && (
Integrasi Sinkronisasi Google Sheets
Hubungkan database bengkel lokal Anda langsung dengan Google Spreadsheet milik pribadi Anda demi pencadangan data tanpa batas secara aman dan otomatis.
Panduan Pemasangan API Google Sheets (Hanya 3 Menit):
Buat Spreadsheet Baru di Google Drive Anda.
Di bilah menu atas Google Sheets, klik menu **Ekstensi** lalu pilih **Apps Script**.
Hapus semua baris kode bawaan di dalam editor tersebut, lalu **Salin & Tempel** seluruh blok kode JavaScript di bawah ini:
{/* CODE SNIPPET AREA FOR THE USER */}
GoogleAppsScript.gs
{
navigator.clipboard.writeText(`function doPost(e) {
try {
var content = JSON.parse(e.postData.contents);
var sheetType = content.sheet;
var dataObj = content.data;
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(sheetType);
if (!sheet) { sheet = ss.insertSheet(sheetType); }
var headers = Object.keys(dataObj);
if (sheet.getLastRow() === 0) {
sheet.appendRow(headers);
} else {
headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
}
var rowValues = headers.map(function(key) {
var val = dataObj[key] !== undefined ? dataObj[key] : "";
return typeof val === 'object' ? JSON.stringify(val) : val;
});
// Mencari kunci unik (id, id_wo, atau kode) untuk melakukan update/timpa
var idToFind = dataObj.id || dataObj.id_wo || dataObj.kode;
var foundRow = -1;
if (idToFind && sheet.getLastRow() > 1) {
var data = sheet.getDataRange().getValues();
var idColIndex = headers.indexOf('id');
if (idColIndex === -1) { idColIndex = headers.indexOf('id_wo'); }
if (idColIndex === -1) { idColIndex = headers.indexOf('kode'); }
if (idColIndex !== -1) {
for (var i = 1; i < data.length; i++) {
if (String(data[i][idColIndex]).trim() === String(idToFind).trim()) {
foundRow = i + 1; break;
}
}
}
}
if (foundRow !== -1) {
sheet.getRange(foundRow, 1, 1, rowValues.length).setValues([rowValues]);
} else {
sheet.appendRow(rowValues);
}
return ContentService.createTextOutput(JSON.stringify({ status: "success", message: foundRow !== -1 ? "Updated row " + foundRow : "Appended new row" })).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ status: "error", error: err.toString() })).setMimeType(ContentService.MimeType.JSON);
}
}`);
triggerToast("Script Code berhasil disalin ke papan klip!", "success");
}}
className="text-cyan-400 hover:text-white font-bold"
>
Salin Kode Script
{`function doPost(e) {
try {
var content = JSON.parse(e.postData.contents);
var sheetType = content.sheet;
var dataObj = content.data;
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(sheetType);
if (!sheet) { sheet = ss.insertSheet(sheetType); }
var headers = Object.keys(dataObj);
if (sheet.getLastRow() === 0) {
sheet.appendRow(headers);
} else {
headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
}
var rowValues = headers.map(function(key) {
var val = dataObj[key] !== undefined ? dataObj[key] : "";
return typeof val === 'object' ? JSON.stringify(val) : val;
});
var idToFind = dataObj.id || dataObj.id_wo || dataObj.kode;
var foundRow = -1;
if (idToFind && sheet.getLastRow() > 1) {
var data = sheet.getDataRange().getValues();
var idColIndex = headers.indexOf('id');
if (idColIndex === -1) { idColIndex = headers.indexOf('id_wo'); }
if (idColIndex === -1) { idColIndex = headers.indexOf('kode'); }
if (idColIndex !== -1) {
for (var i = 1; i < data.length; i++) {
if (String(data[i][idColIndex]).trim() === String(idToFind).trim()) {
foundRow = i + 1; break;
}
}
}
}
if (foundRow !== -1) {
sheet.getRange(foundRow, 1, 1, rowValues.length).setValues([rowValues]);
} else {
sheet.appendRow(rowValues);
}
return ContentService.createTextOutput(JSON.stringify({ status: "success", message: foundRow !== -1 ? "Updated row " + foundRow : "Appended new row" })).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ status: "error", error: err.toString() })).setMimeType(ContentService.MimeType.JSON);
}
}`}
Klik ikon disket (Simpan) di Google Apps Script.
Klik tombol biru **Terapkan** (*Deploy*) > pilih **Penerapan Baru** (*New Deployment*).
Pilih jenis terapkan: **Aplikasi Web** (*Web App*).
Atur aksesibilitas bidang "Yang memiliki akses" (*Who has access*) menjadi **Siapa Saja** (*Anyone*). Ini wajib agar aplikasi React lokal Anda diizinkan mengirim baris data.
Klik **Terapkan**, setujui izin verifikasi Google Akun Anda, lalu **Salin URL Aplikasi Web** yang tampil di layar, kemudian tempelkan di kotak isian input di atas!
)}
© 2026 PT Transjakarta Workshop • Depo Rawa Buaya.
Sistem Integrasi Perawatan Bus Terlengkap v3.5 • Real-time LocalStorage
{/* ================================================== */}
{/* INTERACTIVE MODALS & OVERLAYS */}
{/* ================================================== */}
{/* MODAL ASSIGN MEKANIK (LEADER) */}
{leaderAssignWO && (
Otorisasi Leader
Penugasan & Persetujuan WO: {leaderAssignWO.id}
Bus Body: {leaderAssignWO.noBody} • Referensi Aduan: “{leaderAssignWO.keluhan}”
setLeaderAssignWO(null)} className="text-slate-500 hover:text-white font-bold text-xl leading-none">×
Tunjuk Tim Mekanik Draf Shift
{MECHANICS_LIST.map(name => {
const isSelected = assignedMeks.includes(name);
return (
{
setAssignedMeks(prev =>
prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]
);
}}
className={`p-1.5 rounded-lg font-bold text-center border transition-all ${
isSelected ? 'bg-cyan-500/20 border-cyan-400 text-cyan-300' : 'bg-slate-900 border-slate-855 text-slate-400 hover:text-white'
}`}
>
{name}
);
})}
setLeaderAssignWO(null)} className="bg-slate-950 hover:bg-slate-855 text-slate-400 px-4 py-2 rounded-xl font-bold text-xs">Batal
Setujui & Tugaskan
)}
{/* MODAL REGISTER/EDIT SPAREPART */}
{showAddPartModal && (
)}
{/* MODAL RESET SYSTEM CONFIRMATION */}
{showConfirmReset && (
Konfirmasi Reset Data
Seluruh log perbaikan, data pergantian ban, storing, dan stok gudang akan dikembalikan ke setelan default pabrikan.
setShowConfirmReset(false)} className="bg-slate-950 text-slate-400 border border-slate-855 px-4 py-2 rounded-xl text-xs">Batal
Reset Sekarang
)}
{/* MODAL IMAGE ZOOM VIEW */}
{zoomImgUrl && (
setZoomImgUrl(null)}>
Tutup ×
)}
);
}
function LoginForm({ onSubmitLogin }) {
const [selectedRole, setSelectedRole] = useState('checker');
const [selectedName, setSelectedName] = useState('');
useEffect(() => {
if (selectedRole === 'mechanic') setSelectedName(MECHANICS_LIST[0]);
else if (selectedRole === 'checker') setSelectedName(CHECKERS_LIST[0]);
else if (selectedRole === 'leader') setSelectedName("Alvi (Leader Shift A)");
else setSelectedName("Administrator Utama");
}, [selectedRole]);
return (
{
e.preventDefault();
onSubmitLogin(selectedRole, selectedName);
}}
className="space-y-4 text-xs"
>
Pilih Peran Shift Dinas
setSelectedRole('checker')}
className={`p-3 rounded-xl border text-center font-bold transition-all ${
selectedRole === 'checker' ? 'bg-indigo-600/20 border-indigo-500 text-indigo-300 shadow-md' : 'bg-slate-955 border-slate-850 text-slate-400'
}`}
>
Checker / Inspeksi
setSelectedRole('mechanic')}
className={`p-3 rounded-xl border text-center font-bold transition-all ${
selectedRole === 'mechanic' ? 'bg-emerald-600/20 border-emerald-500 text-emerald-300 shadow-md' : 'bg-slate-955 border-slate-855 text-slate-400'
}`}
>
Mekanik Bengkel
setSelectedRole('leader')}
className={`p-3 rounded-xl border text-center font-bold transition-all ${
selectedRole === 'leader' ? 'bg-cyan-500/20 border-cyan-400 text-cyan-300 shadow-md' : 'bg-slate-955 border-slate-855 text-slate-400'
}`}
>
Leader Mekanik
setSelectedRole('admin')}
className={`p-3 rounded-xl border text-center font-bold transition-all ${
selectedRole === 'admin' ? 'bg-rose-600/20 border-rose-500 text-rose-300 shadow-md' : 'bg-slate-955 border-slate-855 text-slate-400'
}`}
>
Administrator
Pilih / Konfirmasi Nama Operator
{selectedRole === 'mechanic' && (
setSelectedName(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none"
>
{MECHANICS_LIST.map(m => (
{m}
))}
)}
{selectedRole === 'checker' && (
setSelectedName(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none"
>
{CHECKERS_LIST.map(c => (
{c}
))}
)}
{selectedRole === 'leader' && (
setSelectedName(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none"
>
Alvi (Leader Shift A)
Samsul (Leader Shift B)
)}
{selectedRole === 'admin' && (
)}
Mulai Shift Kerja & Masuk Dashboard
);
}